Skip to content

feat(maintenance): Complete maintenance module — Maintenances, Work Orders, Equipment & Parts#215

Open
roncodes wants to merge 66 commits intomainfrom
feat/complete-maintenance-module
Open

feat(maintenance): Complete maintenance module — Maintenances, Work Orders, Equipment & Parts#215
roncodes wants to merge 66 commits intomainfrom
feat/complete-maintenance-module

Conversation

@roncodes
Copy link
Copy Markdown
Member

Overview

This PR completes the existing but stub-only maintenance section in FleetOps. All four maintenance resources — Maintenances, Work Orders, Equipment, and Parts — now have fully functional CRUD flows, following the same conventions used throughout the rest of the extension (e.g. the connectivity section with Layout::Resource::Panel, TabNavigation, and panel-header components).

The sidebar maintenance panel is also uncommented and activated.


Frontend Changes

Sidebar (fleet-ops-sidebar.js)

  • Uncommented and activated the maintenance sidebar panel
  • Added Maintenances as the first item (wrench icon) pointing to maintenance.maintenances
  • All four resources now appear under the Maintenance panel with correct permission guards

Routes (routes.js + new route files)

  • Added maintenances route block (index / new / edit / details / details.index)
  • Fixed work-orders, equipment, and parts details + edit routes: added model() hook and permission guard (were empty stubs)

Controllers

  • Newmaintenances/index: full columns (summary, type, status, priority, scheduled_at, total_cost, created_at), action buttons, bulk delete, query params
  • Newmaintenances/index/new: save task with event tracking and form reset
  • Newmaintenances/index/edit: save task, unsaved-changes guard, cancel/view actions
  • Newmaintenances/index/details: tabs (Overview + registered extension tabs via menuService.getMenuItems), edit/delete action buttons
  • Completedwork-orders/index, equipment/index, parts/index: full column definitions, action buttons, bulk actions
  • Completed — all new/edit/details controllers for work-orders, equipment, parts

Components

  • New work-order/panel-header — displays code/subject, status badge, priority, assigned driver
  • New maintenance/panel-header — displays summary, status badge, type, scheduled_at
  • New equipment/panel-header — displays photo, name, status badge, type, serial number
  • New part/panel-header — displays photo, name, status badge, type, part number

Templates

  • Fixed all new.hbs files: corrected @resource={{this.place}} bug → correct resource binding
  • Fixed all details.hbs files: added @headerComponent (panel-header), TabNavigation with {{outlet}}
  • Fixed all edit.hbs files: added @headerTitle with resource name
  • Fixed all details/index.hbs files: wired to correct ::Details component
  • New — full template tree for maintenances (parent, index, new, edit, details, details/index)

Extension Registries (extension.js)

Added 12 new Universe registries enabling downstream extensions to inject custom tabs and form sections:

fleet-ops:component:maintenance:form
fleet-ops:component:maintenance:form:details
fleet-ops:component:maintenance:details
fleet-ops:component:work-order:form
fleet-ops:component:work-order:form:details
fleet-ops:component:work-order:details
fleet-ops:component:equipment:form
fleet-ops:component:equipment:form:details
fleet-ops:component:equipment:details
fleet-ops:component:part:form
fleet-ops:component:part:form:details
fleet-ops:component:part:details

Services

  • maintenance-actions.js: fixed maintenance.namemaintenance.summary in panel/modal titles

Backend Changes

Auth Schema (server/src/Auth/Schemas/FleetOps.php)

  • Added 4 new resources: maintenance, work-order, equipment, part (each with standard CRUD + export/import actions)
  • Added MaintenanceManager policy: full CRUD on all four resources
  • Updated OperationsAdmin policy: includes all four maintenance resources
  • Added Maintenance Technician role: uses the MaintenanceManager policy

Console Command (server/src/Console/Commands/ProcessMaintenanceTriggers.php)

New artisan command: fleetops:process-maintenance-triggers

  • Time-based triggers: finds scheduled maintenances whose scheduled_at has arrived and transitions them to in_progress
  • Threshold triggers: compares vehicle odometer and engine_hours against next_service_odometer / next_service_engine_hours on scheduled maintenance records
  • Dispatches maintenance.triggered event for downstream extension hooks (no hard-coded foreign keys to external modules)
  • Supports --sandbox and --dry-run flags

Service Provider (server/src/Providers/FleetOpsServiceProvider.php)

  • Registered ProcessMaintenanceTriggers in $commands array
  • Scheduled fleetops:process-maintenance-triggers to run daily

Extensibility Design

All integration points are designed to be consumed by downstream extensions without modifying FleetOps core:

  • The meta JSON column on all maintenance models accepts arbitrary key-value data
  • The Universe registry namespaces allow external extensions to inject tabs, form sections, and context menus
  • The maintenance.triggered event can be listened to by any extension's EventServiceProvider

Testing Checklist

  • Maintenance sidebar panel appears and all four items are visible
  • Maintenances index loads with correct columns and filters
  • Create / Edit / Delete a Maintenance record
  • Create / Edit / Delete a Work Order
  • Create / Edit / Delete an Equipment record
  • Create / Edit / Delete a Part
  • Details panel shows panel-header and Overview tab
  • Permission guards block access for users without the correct policy
  • php artisan fleetops:process-maintenance-triggers --dry-run reports triggers without modifying records
  • php artisan fleetops:process-maintenance-triggers transitions due maintenances to in_progress

Fleetbase Dev and others added 30 commits March 30, 2026 04:11
…k orders, equipment & parts

Completes the existing but incomplete maintenance section in FleetOps,
enabling full CRUD for all four maintenance resources: Maintenances,
Work Orders, Equipment, and Parts.

Frontend:
- Uncomment and activate the maintenance sidebar panel (fleet-ops-sidebar.js)
- Add Maintenances as first sidebar item with wrench icon
- Add maintenances route block to routes.js
- New route files for maintenances (index, new, edit, details, details/index)
- Fix all work-orders/equipment/parts details+edit routes: add model hook and permission guard
- New controllers for maintenances (index, new, edit, details, details/index)
- Complete controllers for work-orders, equipment, parts (full columns, save tasks, tabs, action buttons)
- New panel-header components for all four resources (HBS + JS)
- Fix all new.hbs templates: correct @resource binding (was this.place bug)
- Fix all details.hbs: add @headerComponent, TabNavigation with outlet
- Fix all edit.hbs: add @headerTitle with resource name
- New maintenances templates (maintenances.hbs, index, new, edit, details, details/index)
- Add 12 new Universe registries in extension.js for maintenance/work-order/equipment/part
- Fix maintenance-actions.js: use maintenance.summary instead of maintenance.name

Backend:
- Add maintenance, work-order, equipment, part resources to FleetOps auth schema
- Add MaintenanceManager policy with full CRUD on all four resources
- Update OperationsAdmin policy to include all four maintenance resources
- Add Maintenance Technician role
- New ProcessMaintenanceTriggers artisan command (time-based + odometer/engine-hour triggers)
- Register command and daily schedule in FleetOpsServiceProvider
… sections

- work-order/form: split into Identification, Assignment (polymorphic
  target + assignee with type-driven ModelSelect), Scheduling, and
  Instructions panels. Added targetTypeOptions, assigneeTypeOptions,
  onTargetTypeChange, onAssigneeTypeChange, assignTarget, assignAssignee
  actions. Removed hardcoded 'user' model assumption.

- maintenance/form: split into Identification, Asset & Work Order
  (polymorphic maintainable + performed-by), Scheduling & Readings
  (odometer, engine_hours, scheduled_at, started_at, completed_at),
  Costs (MoneyInput for labor_cost, parts_cost, tax, total_cost), and
  Notes panels. Added full polymorphic type handlers.

- equipment/form: split into Photo, Identification (name, code,
  serial_number, manufacturer, model, type, status), Assignment
  (polymorphic equipable), and Purchase & Warranty panels. Fixed photo
  upload to use fetch.uploadFile.perform pattern. Added
  onEquipableTypeChange / assignEquipable actions.

- part/form: split into Photo, Identification (name, sku, serial_number,
  barcode, manufacturer, model, type, status, description), Inventory
  (quantity_on_hand, unit_cost, msrp with MoneyInput), Compatibility
  (polymorphic asset), and Vendor & Warranty panels. Fixed photo upload
  to use fetch.uploadFile.perform pattern. Added onAssetTypeChange /
  assignAsset actions.

All forms: added MetadataEditor panel, RegistryYield hooks, and
CustomField::Yield. All option arrays cross-checked against PHP model
fillable arrays and fleetops-data Ember models.
… and part forms, fix all ContentPanel wrapperClass

- equipment/form.hbs: remove standalone Photo ContentPanel; photo block
  (Image + UploadButton, matching vehicle/form structure) is now the
  first child of the Identification ContentPanel before the field grid.
- part/form.hbs: same restructure as equipment.
- All four forms (work-order, maintenance, equipment, part): every
  ContentPanel now carries @wrapperclass="bordered-top", including the
  first panel. Previously work-order and maintenance first panels had no
  wrapperClass at all.
- equipment/form.js: equipableTypeOptions converted to { value, label }
  objects; added @Tracked selectedEquipableType; onEquipableTypeChange
  now receives option object and reads option.value.
- part/form.js: assetTypeOptions converted to { value, label } objects;
  added @Tracked selectedAssetType; onAssetTypeChange updated similarly.
- Both HBS files updated to bind @selected to the tracked option object
  and render {{option.label}} in the PowerSelect block.
…d-by type selectors

- maintainableTypeOptions: plain strings -> { value, label } objects
  (Vehicle, Equipment)
- performedByTypeOptions: plain strings -> { value, label } objects
  (Vendor, Driver, User) — added Vendor as a valid performer type
- Added selectedMaintainableType and selectedPerformedByType tracked
  properties so the PowerSelect trigger shows the human-readable label
- Both onChange actions now receive the full option object and write
  option.value to the model attribute
- Updated TYPE_TO_MODEL to include fleet-ops:vendor -> vendor
- HBS PowerSelect @selected bindings updated to use the tracked option
  objects; block params renamed from |type| to |option| with {{option.label}}
…ems panel

- Add migration to add public_id column to maintenances, work_orders,
  equipment, and parts tables (fixes SQLSTATE[42S22] unknown column error)
- Replace flat cost ContentPanel with new Maintenance::CostPanel component
  - Invoice-style line items table with description, qty, unit cost, line total
  - Inline add/edit/remove rows with optimistic UI updates
  - Labour and Tax inputs remain as direct MoneyInput fields
  - Computed totals summary (Labour + Parts + Tax = Total)
  - All mutations hit dedicated API endpoints and reflect server-recomputed totals
- Add addLineItem / updateLineItem / removeLineItem endpoints to MaintenanceController
- Register POST/PUT/DELETE line-item sub-routes in routes.php
Resolves Glimmer reactivity assertion error caused by MoneyInput's
autoNumerize modifier consuming and mutating @resource.currency in
the same render cycle.

Following the vehicle/form.hbs pattern, each form now has a single
CurrencySelect input at the top of the cost/pricing section. All
MoneyInput fields simply read @Currency without @canSelectCurrency
or @onCurrencyChange.

Files changed:
- maintenance/cost-panel.hbs: added CurrencySelect before labour/tax
  inputs; removed @canSelectCurrency from all MoneyInput fields
- equipment/form.hbs: added CurrencySelect before purchase_price;
  removed @canSelectCurrency/@onCurrencyChange from purchase_price
- part/form.hbs: added CurrencySelect before unit_cost/msrp;
  removed @canSelectCurrency/@onCurrencyChange from both fields
…se/fleetops into feat/complete-maintenance-module
- Add Equipment::Card component (photo, type, status, year, quick actions)
- Add Part::Card component (photo, type, qty, unit cost, quick actions)
- Equipment index controller: inject appCache, add @Tracked layout,
  convert actionButtons/bulkActions to getters, add layout toggle dropdown
- Parts index controller: same pattern as Equipment
- Equipment index template: conditional table vs CardsGrid layout
- Parts index template: conditional table vs CardsGrid layout
- Layout preference persisted via appCache (fleetops:equipment:layout,
  fleetops:parts:layout)
Vehicle row dropdown additions:
- Schedule Maintenance → opens schedule form pre-filled with vehicle
- Create Work Order → opens work order form pre-filled with vehicle
- Log Maintenance → opens maintenance form pre-filled with vehicle

Vehicle details panel — 3 new tabs:
- Schedules: lists active maintenance schedules for the vehicle,
  empty state with 'Add Schedule' CTA
- Work Orders: lists work orders targeting the vehicle,
  empty state with 'Create Work Order' CTA
- Maintenance History: lists completed maintenance records,
  empty state with 'Log Maintenance' CTA

Supporting changes:
- vehicle-actions.js: inject scheduleActions/workOrderActions/maintenanceActions,
  add scheduleMaintenance/createWorkOrder/logMaintenance @action methods
- routes.js: add schedules/work-orders/maintenance-history sub-routes under
  vehicles.index.details; add maintenance.schedules top-level route
- Translations: add vehicle.actions.schedule-maintenance/create-work-order/
  log-maintenance; add menu.schedules/maintenance-history;
  add resource.maintenance-schedule(s)
…mmand rewrite

Backend changes:
- Migration: create maintenance_schedules table with interval fields
  (time/distance/engine-hours), next-due thresholds, default assignee,
  and add schedule_uuid FK to work_orders for traceability
- MaintenanceSchedule model: isDue(), resetAfterCompletion(), pause(),
  resume(), complete() methods; polymorphic subject + defaultAssignee
  relationships; workOrders() hasMany
- WorkOrderObserver: on status → 'closed', auto-creates a Maintenance
  history record from completion data stored in work_order.meta and
  calls schedule.resetAfterCompletion() to restart the interval cycle
- ProcessMaintenanceTriggers rewrite: now reads MaintenanceSchedule
  instead of Maintenance; resolves vehicle odometer/engine-hours from
  the polymorphic subject; skips schedules with an existing open WO;
  auto-creates WorkOrder from schedule defaults on trigger
- MaintenanceScheduleController: CRUD via FleetOpsController base +
  custom pause/resume/trigger endpoints
- routes.php: register maintenance-schedules routes with pause/resume/
  trigger sub-routes before work-orders
- FleetOpsServiceProvider: register WorkOrderObserver
…r update, WO completion panel

Frontend changes:
- Sidebar: add 'Schedules' (calendar-alt icon) as first item in the
  Maintenance panel; rename 'Maintenances' entry to 'Maintenance History'
  (history icon) — order is now: Schedules, Work Orders, Maintenance
  History, Equipment, Parts
- MaintenanceSchedule Ember model: full attr mapping for interval fields,
  next-due thresholds, default assignee, status, subject polymorphic
- schedule-actions service: ResourceActionService subclass with
  transition/panel/modal patterns + pause(), resume(), triggerNow() actions
- schedule/form.hbs + form.js: full create/edit form with Schedule Details,
  Asset (polymorphic subject), Maintenance Interval (time/distance/hours),
  and Work Order Defaults (priority, default assignee, instructions) panels
- schedule/details.hbs + details.js: read-only details view component
- Routes: maintenance.schedules.index (+ new/edit/details sub-routes)
- Controllers: schedules/index (columns, actionButtons, bulkActions),
  schedules/index/details (tabs, actionButtons, edit/triggerNow/delete),
  schedules/index/new, schedules/index/edit
- Templates: schedules index (Layout::Resource::Tabular), new overlay,
  edit overlay, details overlay
- work-order/form.hbs: add Completion Details panel (odometer, engine
  hours, labour cost, parts cost, tax, notes) shown only when status
  is set to 'closed'; seeds the WorkOrderObserver auto-log creation
- work-order/form.js: add isCompleting getter + six @Tracked completion
  state fields
… under MySQL 64-char limit

The auto-generated name 'maintenance_schedules_default_assignee_type_default_assignee_uuid_index'
is 73 characters, exceeding MySQL's 64-character identifier limit.
Replaced with explicit short name 'ms_default_assignee_idx'.
…edit/details, fix TYPE_TO_MODEL keys, complete new/edit controllers with save task
… is a real DB column not a computed accessor
…/cell/base across all maintenance controllers
…rvice, calendar, namespace

1. Rename scheduleActions → maintenanceScheduleActions
   - addon/services/schedule-actions.js → maintenance-schedule-actions.js
   - app/services/maintenance-schedule-actions.js re-export added
   - All @service injections and this.scheduleActions refs updated in
     schedules/index, schedules/index/details, vehicle-actions

2. Convert @Tracked actionButtons/bulkActions/columns → getters
   - All 5 maintenance index controllers now use get() instead of @Tracked
   - Prevents Glimmer reactivity assertion errors on render

3. Fix broken @service menuService injection
   - All 5 details controllers: @service menuService →
     @service('universe/menu-service') menuService

4. Rename schedule/ component namespace → maintenance-schedule/
   - addon/components/schedule/ → addon/components/maintenance-schedule/
   - app/components/maintenance-schedule/ re-exports added
   - Templates updated: Schedule::Form/Details → MaintenanceSchedule::Form/Details
   - Class names updated to MaintenanceScheduleFormComponent etc.

5. Add calendar visualization to MaintenanceSchedule::Details
   - details.js: computeOccurrences() + buildCalendarGrid() helpers
   - Navigable month calendar with scheduled dates highlighted in blue
   - Upcoming occurrences list (next 6 dates)
   - Only shown for time-based schedules (interval_method === 'time')
…-orders index getters

The sed-based getter conversion left actionButtons and bulkActions getters
without their closing } in two controllers:
- maintenances/index.js: actionButtons and bulkActions both missing }
- work-orders/index.js: bulkActions missing }

schedules/index.js, equipment/index.js, and parts/index.js were unaffected.
…O tab, vehicle prefill, cost-panel re-export

- ProcessMaintenanceTriggers: auto-generate WO code (WO-YYYYMMDD-XXXXX) and set opened_at on creation
- WorkOrder::Details: full details component with overview, assignment, scheduling, and cost breakdown panels
  (cost breakdown reads from meta.completion_data, shown only when status is closed)
- WorkOrder::Form: add prepareForSave action that packs completion tracked fields into meta before save
- work-orders new/edit controllers: track formComponent and call prepareForSave before workOrder.save()
- Schedules details: add Work Orders tab (route + template) showing all WOs created by this schedule
- vehicle-actions: fix subject_type to use namespaced type strings (fleet-ops:vehicle etc) so schedule form
  pre-selects the correct asset type when opened from the vehicles index row dropdown
- app/components/maintenance/cost-panel.js: add missing re-export shim
- app/components/maintenance/panel-header.js: add missing re-export shim
…hip accessors

Replace all raw _type / _uuid attr reads and writes with proper
@belongsTo relationship accessors across the maintenance module.

Changes:
- addon/models/maintenance-schedule.js
  • Replace subject_type/subject_uuid/subject_name attrs with
    @belongsTo('maintenance-subject', {polymorphic:true}) subject
  • Replace default_assignee_type/default_assignee_uuid attrs with
    @belongsTo('facilitator', {polymorphic:true}) default_assignee
  • Add interval_method attr (was missing)
  • Remove obsolete raw type/uuid attrs

- addon/components/maintenance-schedule/form.js
  • Add MODEL_TO_TYPE + ASSIGNEE_MODEL_TO_TYPE reverse-lookup maps
  • Constructor now reads type from resource.subject.constructor.modelName
    and resource.default_assignee.constructor.modelName instead of raw attrs
  • onSubjectTypeChange / onAssigneeTypeChange clear the relationship
    instead of writing _type/_uuid
  • assignSubject / assignDefaultAssignee set the relationship only

- addon/components/maintenance-schedule/form.hbs
  • @selectedModel binding updated from defaultAssignee → default_assignee

- addon/components/maintenance-schedule/details.hbs
  • Asset field reads subject.displayName|name instead of subject_name

- addon/components/work-order/form.js
  • Add TARGET_MODEL_TO_TYPE + ASSIGNEE_MODEL_TO_TYPE reverse-lookup maps
  • Constructor reads type from target/assignee relationship model names
  • onTargetTypeChange / onAssigneeTypeChange clear relationship only
  • assignTarget / assignAssignee set relationship only

- addon/components/work-order/details.hbs
  • Assignment panel uses target.displayName / assignee.displayName
  • Schedule panel uses schedule.name instead of schedule_uuid

- addon/components/maintenance/form.js
  • Add MAINTAINABLE_MODEL_TO_TYPE + PERFORMED_BY_MODEL_TO_TYPE maps
  • Constructor reads type from maintainable/performed_by relationship
  • onMaintainableTypeChange / onPerformedByTypeChange clear relationship
  • assignMaintainable / assignPerformedBy set relationship only

- addon/components/maintenance/form.hbs
  • @selectedModel binding updated from performedBy → performed_by

- addon/components/maintenance/details.hbs
  • Maintainable / Performed By fields use relationship accessors

- addon/services/vehicle-actions.js
  • scheduleMaintenance: pass { subject: vehicle } only
  • createWorkOrder: pass { target: vehicle } only
  • logMaintenance: pass { maintainable: vehicle } only

- addon/components/vehicle/details/schedules.js
  • Fix service injection: @service scheduleActions → @service('maintenance-schedule-actions')

- addon/components/vehicle/details/schedules.hbs
  • Add Schedule button passes { subject: @vehicle }

- addon/components/vehicle/details/work-orders.hbs
  • Create Work Order button passes { target: @vehicle }

- addon/components/vehicle/details/maintenance-history.hbs
  • Log Maintenance button passes { maintainable: @vehicle }
…se/fleetops into feat/complete-maintenance-module
… non-embedded relationship display

Three fixes applied:

1. Remove console.fleet-ops. route prefix (controllers + routes)
   All transitionTo and tab route strings in addon/controllers/maintenance/
   and addon/routes/maintenance/ were incorrectly prefixed with
   'console.fleet-ops.'.  The host router adds the engine prefix
   automatically, so the routes should start with 'maintenance.' directly.
   Fixed across all 15 controller files and 8 route files.

2. Add _name fallback attrs to details templates
   The subject, default_assignee, target, assignee, maintainable, and
   performed_by relationships are not sideloaded in the server response.
   Details templates now use (or relationship.displayName relationship.name
   resource._name) so the server-side convenience field is shown when the
   relationship object has not yet been loaded.

3. Serializer embedded attrs cleanup (fleetops-data companion commit)
   Removed embedded: always declarations for non-sideloaded polymorphic
   relationships in maintenance-schedule, work-order, and maintenance
   serializers to prevent Ember Data from expecting nested objects that
   the server never returns.
…ic relationships

Backend changes:
- Add $with = ['subject', 'defaultAssignee'] to MaintenanceSchedule model
- Add $with = ['target', 'assignee'] to WorkOrder model (remove from $hidden)
- Add $with = ['maintainable', 'performedBy'] to Maintenance model (remove from $hidden)
- Create Http/Resources/v1/MaintenanceSchedule.php resource transformer
  - Embeds subject and default_assignee via whenLoaded()
  - Outputs raw PHP class name for _type fields (serializer maps them)
- Create Http/Resources/v1/WorkOrder.php resource transformer
  - Embeds target and assignee via whenLoaded()
- Create Http/Resources/v1/Maintenance.php resource transformer
  - Embeds maintainable and performed_by via whenLoaded()

The resource transformers ensure that polymorphic relationship objects are always
included in API responses, enabling the frontend to use embedded: always in its
serializers without needing a separate request to load the related records.
roncodes added 30 commits April 1, 2026 14:02
…se/fleetops into feat/complete-maintenance-module
… maintenance module

- cost-panel.hbs: add @onchange to labor_cost, tax MoneyInput fields; add
  @onchange={{this.setDraftUnitCost}} to inline edit/add row MoneyInput;
  fix column widths using colgroup percentage layout; use format-currency
  helper (cents-based) throughout totals summary and read rows
- cost-panel.js: add setDraftUnitCost @action to receive cents from MoneyInput
  @onchange; document that draftUnitCost and all monetary values are in cents;
  clarify startEdit loads unit_cost already in cents from API
- part/form.hbs: add @onchange={{fn (mut @resource.unit_cost)}} and
  @onchange={{fn (mut @resource.msrp)}} to MoneyInput fields
- equipment/form.hbs: add @onchange={{fn (mut @resource.purchase_price)}} to
  MoneyInput field
- work-order-actions.js: fix prepareForSave to not double-convert cents;
  MoneyInput @onchange already emits cents so toCents (x100) was wrong;
  replaced with toIntCents (parseInt only)
- server models: add Money cast for all monetary attributes (cents storage)
- migration: fix parts table monetary columns to BIGINT for cents storage
… Order email

## Import Functionality (Equipment, Parts, Maintenances, Work Orders, Maintenance Schedules)

### Backend
- Added Import classes: EquipmentImport, PartImport, MaintenanceImport, WorkOrderImport, MaintenanceScheduleImport
  - Each implements Laravel Excel's ToModel and WithHeadingRow contracts
  - Resolves related records (vehicles, vendors, equipment) by name/public_id
  - Monetary values expected in cents (integers) matching Money cast storage
- Added createFromImport() static method to all five Eloquent models
- Added import() action to all five HTTP controllers (EquipmentController, PartController, MaintenanceController, WorkOrderController, MaintenanceScheduleController)
- Registered POST import routes for all five resources in routes.php
- POST /work-orders/{id}/send route registered for Send Work Order feature

### Frontend
- Added Import toolbar button (type: magic, icon: upload) to all five index controllers
- Import templates must be uploaded to S3 at:
  flb-assets.s3.ap-southeast-1.amazonaws.com/import-templates/
  - Fleetbase_Equipment_Import_Template.xlsx
  - Fleetbase_Part_Import_Template.xlsx
  - Fleetbase_Maintenance_Import_Template.xlsx
  - Fleetbase_Work_Order_Import_Template.xlsx
  - Fleetbase_Maintenance_Schedule_Import_Template.xlsx

## Send Work Order Email

### Backend
- Added WorkOrderDispatched Mail class (server/src/Mail/WorkOrderDispatched.php)
- Added work-order-dispatched Blade email template
- Added sendEmail() action to WorkOrderController
  - Resolves assignee email, validates vendor has email, sends mail, logs activity

### Frontend
- Added sendEmail @action to WorkOrderActionsService
  - Shows confirmation modal before sending
  - POSTs to work-orders/{id}/send
  - Shows success/error notification
- Added 'Send Work Order to Vendor' row action in work-orders index controller

## Monetary Attribute Type Fix (fleetops-data)
- Note: @attr('string') fix for monetary fields is in fleetops-data repo (separate commit)
…se/fleetops into feat/complete-maintenance-module
1. Monetary getters (laborCost, tax, partsCost, totalCost, lineTotal,
   draftLineTotal) now wrap values with parseInt(numbersOnly(...)) via a
   _toCents() helper. Required because monetary attrs are @attr('string')
   on the Ember model. Without this, string arithmetic produced NaN.

2. Line item mutations (add, edit, remove) are now purely in-memory.
   Previous implementation made individual API requests to
   fleet-ops/maintenances/{id}/line-items which was broken for new
   unsaved records and used the wrong URI prefix. Now each mutation
   writes a new array back onto @resource.line_items via _commitItems()
   and recomputes parts_cost and total_cost locally. The parent form
   save() call persists everything in a single request for both
   create and edit flows.

3. Removed ember-concurrency tasks from addLineItem, saveEdit,
   removeLineItem - now plain @actions. Updated HBS to use plain
   fn/onClick instead of perform for the affected buttons.

4. Removed @service fetch injection (no longer needed).
… details

- Created modals/send-work-order.hbs — a component-based confirmation modal
  that displays:
    - Work order subject (or target_name fallback), public_id, status, and
      due date so the user can confirm they have the correct work order
    - Vendor card showing name, email, and phone so the user knows exactly
      who the email will be sent to
    - Amber warning when no vendor is assigned
    - Red error banner when the vendor has no email address on file, with
      the Send button disabled to prevent a failed send

- Created modals/send-work-order.js — minimal Glimmer component class

- Updated work-order-actions.js sendEmail action:
    - Replaced modalsManager.confirm() with modalsManager.show() using the
      new component-based modal
    - Resolves vendorName, vendorEmail, vendorPhone from workOrder.assignee
      with fallback to assignee_name for display-only contexts
    - Disables the accept button when vendorEmail is absent
    - Runs the POST request inside modal.startLoading()/modal.done() for
      proper loading state feedback

- Updated work-orders/index/details.js:
    - Added sendEmail @action that delegates to workOrderActions.sendEmail
    - Added 'Send to Vendor' button (paper-plane icon) as the first
      actionButton in the details panel toolbar
… on boot

- Removed 'slug' from $fillable — the work_orders table has no slug column,
  causing the SQLSTATE[42S22] column not found error on insert
- Added boot() method with a static::creating() hook that generates a unique
  WO-XXXXXXXX code (8 random uppercase chars) when no code is provided
- Added Illuminate\Support\Str import for Str::random()
…and maintenances

- Add public_id column directly into create_equipments_table, create_parts_table,
  create_work_orders_table, and create_maintenances_table migrations so the column
  is always present on fresh installs (HasPublicId::generatePublicId queries this
  column before insert; missing column caused SQLSTATE 42S22 errors)
- The existing add_public_id_to_maintenance_tables migration already has
  Schema::hasColumn guards so it remains safe to run on both fresh and existing DBs
- Remove photo_uuid from Equipment and Part fillable arrays (no such column exists
  in either table; caused SQLSTATE 42S22 on insert)
- Remove slug from Maintenance fillable (no slug column in maintenances table)
- Change unit_cost/msrp in create_parts_table migration from decimal(12,2) to
  bigInteger (cents) to match the Money cast and monetary storage standard
- Guard fix_monetary_columns_in_parts_table migration with column type check so
  it is idempotent on fresh installs that already have bigInteger columns
- Revert the incorrect public_id additions to the original create_* migrations.
  Those migrations have already shipped in previous releases and would never re-run
  on existing installs. The existing add_public_id_to_maintenance_tables migration
  (with Schema::hasColumn guards) is the correct mechanism for existing deployments.

- Add new migration 2026_04_01_000003_add_photo_uuid_to_equipment_and_parts_tables
  which adds the photo_uuid FK column to equipments and parts tables. Both models
  have a photo() BelongsTo relationship and getPhotoUrlAttribute() accessor but the
  backing column was missing from the original create migrations. The migration is
  guarded with Schema::hasTable and Schema::hasColumn checks so it is safe to run
  on both fresh and existing databases.

- Restore photo_uuid to Equipment and Part $fillable arrays now that the column
  will exist after the migration runs.
…ble/performedBy in API response

- Add setLineItemsAttribute mutator on Maintenance model using Money::apply() to
  strip currency symbols and convert formatted strings (e.g. 'S$100.00') to
  cents integers (e.g. 10000) before persisting to the line_items JSON column
- Add onAfterCreate and onAfterUpdate hooks to MaintenanceController that call
  $record->load(['maintainable', 'performedBy']) so both polymorphic relationships
  are always embedded in the create/update API response without requiring the
  frontend to pass ?with= query params
Laravel's getMorphs() always derives the foreign-key column as {name}_id.
Every Fleetbase model uses {name}_uuid instead, so bare morphTo() calls
were silently looking up a non-existent column and returning null.

Fixed by passing __FUNCTION__, type_column, uuid_column to every morphTo():

  Maintenance  : maintainable()  -> maintainable_type / maintainable_uuid
                 performedBy()   -> performed_by_type  / performed_by_uuid
  WorkOrder    : target()        -> target_type        / target_uuid
                 assignee()      -> assignee_type      / assignee_uuid
  Equipment    : equipable()     -> equipable_type     / equipable_uuid
  Part         : asset()         -> asset_type         / asset_uuid
  Asset        : assignedTo()    -> assigned_to_type   / assigned_to_uuid
                 operator()      -> operator_type      / operator_uuid
  Device       : attachable()    -> attachable_type    / attachable_uuid
  Sensor       : sensorable()    -> sensorable_type    / sensorable_uuid
  Warranty     : subject()       -> subject_type       / subject_uuid

This is the root cause of maintainable and performedBy always being null
in Maintenance API responses despite $with = ['maintainable','performedBy']
being declared on the model.
The original `add_public_id_to_maintenance_tables` migration (054932)
referenced the table as 'equipment' (singular) instead of 'equipments'
(plural — the actual table name declared in the Equipment model).

Because that migration had already been recorded in the `migrations`
table on existing deployments, Laravel would never re-run it, meaning
the `equipments` table never received the `public_id` column. This
caused the following error on every Equipment create/save:

  SQLSTATE[42S22]: Column not found: 1054 Unknown column 'public_id'
  in 'where clause' (HasPublicId::generatePublicId uniqueness check)

Fix: add a new, fully-idempotent migration
`2026_04_01_000004_add_public_id_to_equipments_table` that uses
Schema::hasTable + Schema::hasColumn guards before adding the column,
so it is safe to run on both fresh installs (where the column may
already exist from a corrected create migration) and existing databases
that are missing the column.
The original create_parts_table migration omitted three columns that the
Part model declares in $fillable and uses during inserts:

  - public_id  : unique human-readable identifier (HasPublicId trait)
  - status     : part lifecycle / stock status (e.g. 'in_stock')
  - currency   : ISO 4217 code for monetary columns (unit_cost, msrp)

Without these columns every Part creation attempt threw:
  SQLSTATE[42S22]: Column not found: 1054 Unknown column 'status' in 'field list'

The new idempotent migration adds all three columns with Schema::hasColumn
guards so it is safe on both fresh installs and existing deployments.
…ction

setTargetType / setAssigneeType / setMaintainableType / setPerformedByType /
setSubjectType all previously called Utils::toEmberResourceType() to build the
injected subject_type / facilitator_type strings, which produced values like
'facilitator-fleet-ops:vendor' instead of 'facilitator-vendor'.

The root cause is that toEmberResourceType('Fleetbase\FleetOps\Models\Vendor')
returns 'fleet-ops:vendor', so concatenating 'facilitator-' in front yields the
invalid string 'facilitator-fleet-ops:vendor'. Ember Data's normalizePolymorphicType
hook could not map this to any known model name, causing it to fall back to the
abstract base model ('facilitator' / 'maintenance-subject'). On the next save,
serializePolymorphicType would then write the base class name
(Fleetbase\FleetOps\Models\Facilitator) into the DB column - a class that
does not exist - breaking all subsequent eager-loads and the sendEmail action.

Fix: derive the bare slug from class_basename() + Str::kebab() instead:
  class_basename('Fleetbase\FleetOps\Models\Vendor') -> 'Vendor'
  Str::kebab('Vendor') -> 'vendor'
  'facilitator-' . 'vendor' -> 'facilitator-vendor'

Affected resources: WorkOrder, Maintenance, MaintenanceSchedule.
…r models

Set the 'type' field on embedded polymorphic objects to the concrete
Ember Data model name instead of the abstract base, so that
EmbeddedRecordsMixin._normalizeEmbeddedRelationship resolves the right
model class via store.modelFor(hash.type).

- Order: facilitator -> 'facilitator-vendor' / 'facilitator-contact' etc.
         customer   -> 'customer-contact' / 'customer-vendor' etc.
- WorkOrder: target   -> 'maintenance-subject-vehicle' / '-equipment'
             assignee -> 'facilitator-vendor' / 'facilitator-contact'
- Maintenance: maintainable -> 'maintenance-subject-vehicle' / '-equipment'
               performed_by -> 'facilitator-vendor' / 'facilitator-contact'
- MaintenanceSchedule: subject          -> 'maintenance-subject-*'
                       default_assignee -> 'facilitator-*'

All setXxxType helpers now derive the bare slug via
Str::kebab(class_basename($class)) to avoid the 'fleet-ops:' prefix
that toEmberResourceType() would produce.
The x-mail-layout Blade component wraps x-mail::layout which is a
Laravel Markdown mail component. It only resolves correctly when the
Mailable uses markdown: in Content, not view:. Using view: caused
'No hint path defined for [mail]' because the mail:: namespace is
only registered for Markdown mailables.

Matches the pattern used by all core-api mail classes.
…lendar feed

1. Reminder emails (multi-offset)
   - New migration: adds `reminder_offsets` (JSON array, e.g. [15, 3]) to
     `maintenance_schedules` and creates `maintenance_schedule_reminders`
     tracking table to prevent duplicate sends per cycle per offset
   - New command: `fleetops:send-maintenance-reminders` — runs daily, sends
     MaintenanceScheduleReminder mail to defaultAssignee for each due offset
     that has not yet fired for the current `next_due_date` cycle
   - New mail: `MaintenanceScheduleReminder` (markdown, mirrors WorkOrderDispatched)
   - New view: `resources/views/mail/maintenance-schedule-reminder.blade.php`
   - Registered command in FleetOpsServiceProvider + scheduled daily

2. iCal export
   - GET /maintenance-schedules/{id}/ical — returns a .ics file for the
     schedule's next due date using spatie/icalendar-generator
   - Added `spatie/icalendar-generator` to composer.json

3. Calendar feed (for FullCalendar frontend)
   - GET /maintenance-schedules/calendar-feed?start=&end= — returns JSON
     event array for all active schedules with next_due_date in the given
     window (defaults to next 90 days); includes title, date, priority
     colour, subject name, and assignee name

4. Model / Resource
   - `reminder_offsets` added to MaintenanceSchedule fillable + cast as array
   - `reminder_offsets` included in v1 resource response
…and calendar view

## 1. Reminder Offsets (form + details)

- **form.hbs / form.js**: Added a "Reminder Emails" `ContentPanel` below the
  schedule body. Users can type a number of days and press Enter (or click Add)
  to append an offset badge. Each badge has an × button to remove it.
  `reminderOffsets` is a tracked array that stays in sync with
  `resource.reminder_offsets` on every mutation.

- **details.hbs / details.js**: A "Reminder Emails" read-only panel is shown
  when `resource.reminder_offsets` is non-empty, listing each offset as a badge
  (e.g. "7 days before").

## 2. iCal Export (details)

- **details.hbs**: Added a "Download .ics" button in the calendar export section.
- **details.js**: `downloadIcal(schedule)` uses `fetch.download()` — which
  automatically attaches auth headers and reads `Content-Disposition` /
  `Content-Type` from the response — to trigger a browser download of the
  `.ics` file served by `GET /maintenance-schedules/:id/ical`.

## 3. Google Calendar deep-link (details)

- **details.hbs**: Added an "Add to Google Calendar" button.
- **details.js**: `addToGoogleCalendar(schedule)` constructs a
  `calendar.google.com/calendar/render?action=TEMPLATE&…` URL from the
  schedule's `name`, `next_due_date`, and `description`, then opens it in a
  new tab.

## 4. Calendar view toggle (index)

- **index.hbs**: Added a List / Calendar toggle above the tabular layout.
  When "Calendar" is active the `<FullCalendar>` component is rendered instead
  of the table.

- **index.js**:
  - `viewMode` (`'list'` | `'calendar'`) is persisted to `localStorage`.
  - `calendarEventSource` is a getter that returns a **function-based event
    source** — the correct FullCalendar v6 pattern for lazy/dynamic loading.
    FullCalendar calls the function with `(fetchInfo, successCallback,
    failureCallback)`; we call `fetch.get('maintenance-schedules/calendar-feed',
    { start, end })` and map the response to FullCalendar event objects with
    status-based colours.
  - `onCalendarEventClick` extracts `extendedProps.public_id` from the clicked
    event and calls `maintenanceScheduleActions.transition.view({ public_id })`.
  - Added `scheduleActions` getter alias so the existing `@onSearch` template
    binding continues to work.
…ton layout switcher

Match the vehicles index pattern exactly:
- Remove standalone List/Calendar toggle buttons from the template
- Add a 'dropdown-button' action item (icon: 'display') as the first entry in
  actionButtons — identical structure to ManagementVehiclesIndexController
- Persist the selected layout ('list' | 'calendar') via appCache under the key
  'fleetops:maintenance-schedules:layout' (replaces localStorage approach)
- Rename tracked property from viewMode -> layout to match the vehicles pattern
- Calendar view now wraps FullCalendar inside Layout::Section::Header +
  Layout::Resource::TabularActions so the toolbar (search, action buttons) is
  still visible when in calendar mode
- Add 'calendar-view' and 'change-layout' i18n keys to all 7 locale files
…API response

The calendar-feed endpoint returns { events: [...] } but the event source
function was treating the whole response object as an array, so successCallback
received [] and nothing rendered on the calendar.

Changes:
- Unwrap via: Array.isArray(response) ? response : (response?.events ?? [])
  so both a bare array and the wrapped { events } shape are handled
- Map end date from the response (was missing before)
- Use backend-supplied event.color directly instead of re-computing from status
- Expand extendedProps to include uuid, subject_name, assignee_name, and type
…e details actions dropdown; fix iCal RRULE recurrence

- maintenance-schedule details: remove Calendar Export ContentPanel section from HBS;
  add ellipsis-h dropdown in actionButtons with 'Download .ics' and 'Add to Google Calendar' items
  (matches the dropdown-button pattern used across the codebase)
- maintenance-schedule details controller: move downloadIcal and addToGoogleCalendar actions here
  from the component; Google Calendar URL now includes RRULE recurrence (interval_value + interval_unit)
- vehicle details controller: add ellipsis-h actions dropdown in actionButtons mirroring the
  index table row actions (locate, schedule maintenance, create work order, log maintenance, delete)
- PHP ical() method: add spatie/icalendar-generator RRule with RRULE frequency + interval so
  downloaded .ics files include repeating occurrences for time-based maintenance schedules
…ate window

Previously calendarFeed used whereBetween('next_due_date', [start, end]) which
only matched a schedule if its stored next_due_date column fell inside the
FullCalendar-requested window. Navigating to future months sent a new start/end
range that no longer contained the original next_due_date, so the query returned
nothing and the calendar appeared empty.

Fix: broaden the DB query to fetch all active schedules whose next_due_date is
<= windowEnd, then expand recurring schedules in PHP:
- Walk forward from next_due_date in steps of (interval_value interval_unit)
- Emit one event object per occurrence that falls inside [windowStart, windowEnd]
- Each occurrence gets a unique id (public_id + '-' + date) so FullCalendar
  renders them as separate events
- One-off schedules (no interval) are only emitted when their date is in range
- Safety cap of 500 occurrences per schedule per request
… occurrences

Appending the date to the id broke the onCalendarEventClick handler which reads
event.id (= public_id) to navigate to the schedule details panel. Each occurrence
now carries the plain public_id as its id; the occurrence date is still included
as a top-level occurrence_date field for any future tooltip/display use.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant